第3章 套接字编程简介

3.2 套接字的地址结构

大多数套接字函数都需要一个指向套接字地址结构的指针作为参数。每个协议族都定义自己的套接字地址结构,这些结构的名字均已sockaddr_开头,并以对应每个协议族的唯一后缀结尾。

IPv4套接字地址结构:

struct in_addr{
    in_addr_t          s_addr;
};
struct sockaddr_in{
    uint8_t            sin_len;
    
    //POSIX规范只需要这个结构中的三个字段
    sa_family_t        sin_family;
    in_port_t          sin_port;
    struct in_addr     sin_addr;
    
    char               sin_zero[8];
};
字段数据类型说明
s_addrin_addr_t至少32位的无符号整数类型
sin_portin_port_t至少16位的无符号整数类型
sin_familysa_family_t任何无符号整数类型。在支持长度字段的实现中,通常是一个8位无符号整数,在不支持长度字段中,是一个16位的无符号整数

套接字地址结构仅在给定主机上使用:虽然结构中某些字段用在不同主机之间的通信,但是结构本身并不在主机之间传递。

为了让套接字函数能够处理来自任何协议族的套接字地址结构,套接字函数定义的参数中使用指向通用套接字地址结构的指针,使用时再进行类型强制转换

通用套接字地址结构:

struct sockaddr{
    uint8_t            sa_len;		//该字段只在一些Unix实现中有
	    						  	//SuSv3标准不做要求,Linux实现也不存在该字段
    sa_family_t        sa_family;
    char               sa_date[14];
};

IPv6套接字地址结构:

struct in6_addr{
    uint8_t            s6_addr[16];
};

//如果系统支持套接字地址结构中的长度字段,则SIN6_LEN常值必须定义
#define SIN6_LEN

struct sockaddr_in6{
    uint8_t            sin6_len;
    sa_family_t        sin6_family;
    in_port_t          sin6_port;
    
    uint32_t           sin6_flowinfo;
    struct in6_addr    sin6_addr;
    
    uint32_t           sin6_scope_id;
}

新的通用套接字地址结构

struct sockaddr_storage{
    uint8_t        ss_len;
    sa_family_t    ss_family;
}

套接字地址结构比较:

1579573720073

3.3 值-结果参数

套接字的地址结构总是以引用形式传递给套接字函数的。

套接字的长度作为一个参数传递给套接字函数时,其传递方式取决于该结构的传递方向。

套接字地址结构可以在两个方向上传递:

  • 从进程到内核。函数:bind、connect、sendto。

    这些函数的一个参数是指向套接字地址结构的指针,另一个参数是该结构的整数大小。

  • 从内核到进程。函数:accept、recvfrom、getsockname、getpeername。

    这些函数的一个参数是指向套接字地址结构的指针,另一个参数是指向表示该结构大小的整数变量的指针(这种类型的参数称为“值-结果”参数)。

    值-结果传参:
    当函数被调用时,结构大小是一个值,它告诉内核该结构的大小,这样内核在写该结构时不至于越界。
    当函数返回时,结构大小又是一个结果,它告诉进程内核在该结构中究竟存储了多少信息。
    

当套接字地址结构的长度使用值-结果参数时,如果套接字地址结构是固定长度则从内核返回的值总是那个长度,如果是可变长度,则返回值可能小于该结构的最大长度。

3.4 字节排序函数

主机字节序:

  • 小端字节序:将低序字节存储在起始地址
  • 大端字节序:将高序字节存储在起始地址

1579582483045

最高有效位:MSB:most significant bit

最低有效位:LSB:least significant bit

术语“小端”和“大端”表示:多个字节值的哪一端(小端或大端)存储在该值的起始地址(低地址)。

网络字节序:大端字节序

网络协议必须指定一个网络字节序。由于历史原因和POSIX规范的规定,套接字地址结构中的某些字段必须按照网络字节序进行维护。

主机字节序和网络字节序之间相互转换使用以下4个函数:

  • s视为一个16位的值,例如TCP或UDP的端口号
  • l视为一个32位的值,例如IPv4地址
  • 主机字节序和网络字节序相同的系统中这四个函数定义为空宏
//主机:host(h)
//网络:network(n)
//短整型:short(s)
//长整型:long(l)
#include <netinet/in.h>

//返回网络字节序的值
uint16_t htons(uint16_t host16bitvalue);
uint32_t htonl(uint32_t host32bitvalue);

//返回主机字节序的值
uint16_t ntohs(uint16_t net16bitvalue);
uint32_t ntohl(uint32_t net32bitvalue);

因特网另一个重要的约定是位序,IPv4首部前32位的位序如下:

1579584598760

3.5 字节操作函数

操作多字节段的函数有两组,它们既不对数据作解释,也不假设数据是以空字节符结束的C字符串。

  • 第一组函数源于4.2BSD,名字以b(表示字节)开头

    #include <strings.h>
    
    void bzero(void *dest, size_t nbytes);
    void bcopy(const void *src, void *dest, size_t nbytes);
    //若相等则返回0,否则为非0
    int bcmp(const void *ptr1, const void *ptr2, size_t nbytes);
    
  • 第二组函数源于ANSI C标准,名字以men(表示内存)开头

    #include <string.h>
    
    //每个函数的最后一个参数都是长度参数
    void *memset(void *dest, int c, size_t len);
    //memcpy函数的参数顺序与C的赋值语句顺序相同:dest = src
    void *memcpy(void *dest, const void *src, size_t nbytes);
    //若相等则返回0,否则
    //    看第一个不等字节:ptr1 > ptr2,则返回值大于0,否则返回值小于0
    int memcmp(const void *ptr1, const void *ptr2, size_t nbytes);
    

当源字节串与目标字节串重叠时,bcopy能够正确处理,memcpy的操作结果却不可知,这种情况必须改用ANSI C的memmove函数。

比较操作是假设两个不等字节均为无符号字符(unsigned char)的情况下完成的。

3.6 inet_aton、inet_addr和inet_ntoa函数

功能介绍:在点分十进制数串和它长度为32位的网络字节序二进制值间转换IPv4地址

#include <arpa/inet.h>

//若字符串有效,则返回1,否则返回0
//如果addrptr指针为空,那么该函数仍然对输入的字符串执行有效性检查,但是不存储任何结果。
int inet_aton(const char *strptr, struct in_addr *addrptr);

//若字符串有效,则返回32位二进制网络字节序的IPv4地址,否则返回INADDR_NONE
//NADDR_NONE常值通常是一个32位均为1的值,这意味着点分十进制数串255.255.255.255不能由该函数处理,因为其二进制值被用来指示函数失败。
in_addr_t inet_addr(const char *strptr);

//返回一个点分十进制数串的指针
char *inet_ntoa(struct in_addr inaddr);

3.7 inet_pton和inet_ntop函数

这两个函数对于IPv4地址和IPv6地址都适用。函数名中p和n分别代表表达(presentation)数值(numeric)

#include <arpa/inet.h>

//函数执行成功返回1,表达的格式无效返回0,出错返回-1
int inet_pton(int family, const char *strptr, void *addptr);

//函数执行成功返回指向结果的指针,出错返回NULL
const char *inet_ntop(int family, const void *addptr, char *strptr, size_t len);

//family参数可以是AF_INET,也可以是AF_INET6,如果以不被支持的地址族作为family参数,两个函数就都返回一个错误,并将errno置为EAFNOSUPPORT

总结5个函数

1579590914742

3.8 sock_ntop和相关函数

本书编写的协议无关性函数。函数名以sock_开头。

#include "unp.h"

//成功返回非空指针,出错返回NULL
char *sock_ntop(const struct sockaddr * sockaddr, socklen_t addrlen);

//成功返回0,出错返回-1
int sock_bind_wild(int sockfd, int family);

//若地址为同一协议族且相同,则返回0,反则返回非0
int sock_cmp_addr(const struct sockaddr *sockaddr1,
                  const struct sockaddr *sockaddr2, socklen_t addrlen);

//若地址为同一协议族且端口相同,则返回0,反则返回非0
int sock_cmp_addr(const struct sockaddr *sockaddr1,
                  const struct sockaddr *sockaddr2, socklen_t addrlen);

//返回:若为IPv4或IPv6地址则为非负端口号,否则为-1
int sock_get_port(const struct sockaddr *sockaddr, socklen_t addrlen);

//成功返回非空指针,出错返回NULL
char *sock_ntop_host(const struct sockaddr *sockaddr, socklen_t addrlen);

void sock_set_addr(const struct sockaddr *sockaddr, 
                   socklen_t addrlen, void *ptr);
void sock_set_port(const struct sockaddr *sockaddr,
                   socklen_t addrlen, int port);
void sock_set_wild(sturct sockaddr *sockaddr, socklen_t addrlen);

3.9 readn、writen和readline函数

字节流套接字上的read和write函数所表现的行为不同于通常文件的I/O。字节流套接字上调用read或write输入或输出的字节数可能比请求的数量少,然而这不是出错状态。原因在于:内核中用于套接字的缓冲区可能已经达到极限。此时需要的是调用者再次调用read或write函数,输入或输出剩余的字节。

为了预防万一,不让返回的字节计数值不足,编写了三个函数。

#include "unp.h"

ssize_t readn(int filedes, void *buff, size_t nbytes);
ssize_t written(int filedes, const void *buff, size_t nbytes);
ssize_t readline(int filedes, void *buff, size_t maxlen);

第4章 基本TCP套接字编程

4.1 概述

TCP客户与服务器进程之间发生的一些典型事件的时间表:

1579936030580

4.2 socket函数

为了执行网络I/O,一个进程必须做的第一件事就是:调用socket函数,指定期望的通信协议类型

#include <sys/socket.h>
//成功返回一个小的非负整数值,即套接字描述符(sockfd),若出错返回-1
int socket(int family, int type, int protocol);
  • family参数指明协议族(该参数又被称为协议域),是一个常值。

1579936305279

  • type参数指明套接字类型,是一个常值。

1579936393623

  • protocol参数设置为对应协议类型的常值。

1579936706600

并非所有套接字family和type的组合都是有效的:

1579937354376

AF_前缀表示地址族,PF_前缀表示协议族。

历史想法:单个协议族可以支持多个地址族,PF_值用来创建套接字,而AF_值用于套接字地址结构。实际上,支持多个地址族的协议族从来就未实现过,头文件<sys/socket.h>中为一给定协议定义的PF_值总是与此协议的AF_值相等。

4.3 connect函数

TCP客户用connect函数来建立与TCP服务器的连接。

#include <sys/socket.h>
//成功返回0,出错返回-1
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);
  • 参数1:由socket函数返回的_套接字描述符_。
  • 参数2:一个指向套接字地址结构的指针
  • 参数3:套接字地址结构的大小

客户在调用connect函数前不必非得调用bind函数,如果需要的话内核会确定源IP地址并选择一个临时端口作为源端口。

TCP套接字

如果是TCP套接字,调用connect函数将激发TCP三次握手过程,连接成功或失败才返回。

出错情况:

  1. ETIMEDOUT错误,未收到SYN分节的响应(超时重传后)。

  2. ECONNREFUSED错误,客户的SYN响应是RST(复位),表明服务器主机在指定的端口上没有进程在等待与之连接。这是一种硬错误。客户收到RST后立马返回错误。

    产生RST的三个条件:
    1. 目的地为某端口的SYN到达,然而该端口上没有正在监听的服务器
    2. TCP想取消一个已有的连接
    3. TCP接收到一个根本不存在的连接上的分节
    
  3. EHOSTUNREACH~~或ENETUNREACH~~错误,客户发出的SYN在发生的过程中遇到“destination unreachable”(目的地不可到达)ICMP错误。这是一种软错误。重传后仍未被响应则返回错误给进程。

    产生EHOSTUNREACH错误的其他可能情况:
    1. 按照本地系统的转发表,根本没有到达远程系统的路径
    2. connect调用根本不等待就返回
    

按照TCP状态转换图,connect函数导致当前套接字从CLOSED状态(该套接字自从由socket函数创建以来一直所在的状态)转移到SYN_SENT状态,若成功再转移到ESTABLISHED状态。

若connect函数返回失败后,当前套接字必须close不可以再使用,当重新connect之前必须重新调用socket函数。

4.4 bind函数

把一个本地协议地址赋予一个套接字。对于网际网协议,协议地址是32位的IPv4地址或128位的IPv6地址与16位的TCP或UDP端口号的组合。

#include <sys/socket.h>
//成功返回0,出错返回-1
int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);
  • 参数1:由socket函数返回的_套接字描述符_。
  • 参数2:一个指向特定协议的地址结构的指针
  • 参数3:该地址结构的长度

函数返回的一个常见错误:EADDRINUSE(“Address already in use”,地址已使用)

对于TCP

调用bind函数可以指定一个端口号,或指定一个IP地址,也可以两者都指定,还可以两者都不指定。

  • 服务器一般捆绑熟知端口号,远程过程调用(RPC)服务器例外
  • 客户一般由内核为套接字选择一个临时端口,除非应用需要一个预留端口
  • 客户将一个特定的IP绑定到套接字,则为在该套接字上发送的IP数据报指定了源IP地址
  • 服务器将一个特定的IP绑定到套接字,则限定该套接字只接收那些目的地址为该IP地址的客户连接
  • TCP客户通常不把IP地址捆绑到它的套接字上,当连接套接字时,内核根据外出网络接口来选择源IP地址
  • TCP服务器没有捆绑IP地址,内核就把客户的SYN的目的地址作为服务器的源IP地址

1579959335440

IPv4:设置sin_addr和sin_port

通配地址由常值INADDR_ANY来指定,其值一般为0,无论主机字节序还是网络字节序值为0都一样,因此使用htonl并非必需。

struct sockaddr_in servaddr;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);

IPv6:设置sin6_addr和sin6_port

struct sockaddr_in6 serv;
serv.sin6_addr = in6addr_any;
//系统预先分配in6addr_any变量并将其初始化为常值IN6ADDR_ANY_INIT

内核为套接字选择临时端口号:调用函数getsockname返回协议地址。

进程捆绑非通配IP地址:一个主机提供多个服务,根据IP地址访问相对应的服务页面

4.5 listen函数

listen函数仅由TCP服务器调用,它做两件事:

  1. 当socket创建一个套接字时,默认为主动套接字,是一个客户套接字,可以调用connect发起连接。listen函数将其转换为被动套接字,指示内核应该接受指向该套接字的连接请求。
  2. 函数的第二个参数规定内核应该为相应套接字排队的最大连接个数
  3. 根据TCP转换图,调用listen导致套接字从CLOSED状态转换到LISTEN状态。
#include <sys/socket.h>
//成功返回0,出错返回-1
int listen(int sockfd, int backlog);

内核为每个监听套接字维护两个队列:

  1. 未完成连接队列。客户SYN到达服务器但未完成三次握手。
  2. 已完成连接队列。完成三次握手。

1579965675751

每当在未完成连接队列中创建一项时,来自监听套接字的参数就复制到即将建立的连接中,连接的创建机制是完成自动的,无需服务器进程插手。

image-20200605103311037

当进程调用accept时,已完成连接队列的队头项返回给进程,如果此队列为空则该进程投入睡眠,直到已完成连接队列放入一项才唤醒。

关于两个队列的处理,有几点考虑:

  • listen函数的backlog参数曾经被规定为两个队列总和的最大值,4.2BSD手册页面定义为:由未处理连接构成的队列可能增长到的最大长度

  • 源自Berkeley的实现给backlog增设了一个模糊因子:把它乘以1.5得到未处理队列的最大长度

  • 不要把backlog定义为0

  • 未完成连接队列中任何一项的存留时间是一个RTT(三次握手正常完成的情况下)

  • 历来沿用的样例代码总是给出值为5的backlog

  • backlog的值为5往往不够,可以通过命令行选项或环境变量覆盖该默认值,若指定值超过内核支持的最大值,则内核将指定的偏大值截成自身支持的最大值,不返回错误

  • ~~手册和书本历来称:将固定数目的未处理连接排成队列是为了处理服务器进程在相继的accept调用之间处于忙状态的情况。这隐含的意思是:已完成队列通常比未完成队列有更多的项。~~繁忙的服务器表明这是不对的,指定较大的backlog值的理由在于:随着客户SYN分节的到达,未完成连接队列中的项数可能增长,它们等着三次握手的完成。

  • 当客户SYN到达时,若这些队列是满的,TCP就忽略这分节而不发送RST,让客户进行重传

    客户无法区别响应SYN的RST究竟意味什么,可能情况是:
    1. 该端口没有服务器在监听
    2. 该端口有服务器在监听,不过队列满了
    
  • 三次握手完成后,调用accept之前,到达的数据由服务器TCP排队,最大数据量为相应已连接套接字的接收缓冲区大小

回味listen的backlog参数的确切含义:它应该指定某个给定套接字上内核为之排队的最大已完成连接数。对已完成连接数作出限制的目的在于:在监听某个给定套接字的应用进程(不论什么原因)停止接受连接的时候,防止内核在该套接字上继续接受新的连接请求(客户的connect)。

4.6 accept函数

accept函数由TCP服务器调用,用于从已完成连接队列队头返回下一个已完成连接。如果队列为空,那么进程被投入睡眠(假定套接字为默认的阻塞方式)。

#include <sys/socket.h>
//成功返回非负描述符,出错返回-1
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);

参数cliaddr和addrlen用来返回已连接的对端进程(客户)的协议地址。addrlen是值-结果参数。

accept成功返回一个由内核自动生成的_已连接套接字描述符_,代表与所返回客户的TCP连接。当服务器完成对某个给定的客户的服务时,相应的已连接套接字就被关闭。

accept函数的第一个参数为_监听套接字描述符_。一个服务器通常仅仅创建一个监听套接字,它在服务器的生命周期内一直存在。

4.7 fork和exec函数

fork函数是Unix中派生新进程的唯一方法,调用一次fork,返回两次结果

  1. 在调用进程(称为父进程)中返回一次,告知新派生进程(称为子进程)的进程ID号
  2. 在子进程中返回一次,返回值为0,告知当前进程是子进程还是父进程

这样设计的原因是:任何子进程只有一个父进程,子进程可以通过getppid获取父进程ID。而父进程可以有多个子进程,而无法获取各个子进程的进程ID。

#include <unistd.h>
//在子进程中返回0,在父进程中返回子进程ID,出错返回-1
pid_t fork(void);

父进程中调用的fork之前打开的所有描述符在fork返回之后由子进程分享。网络服务器利用此特性:父进程accept之后调用fork,所接受的已连接套接字在父进程和子进程之间共享,通常情况下,子进程接着读写这个已连接套接字,父进程则关闭这个已连接套接字。

fork有两个典型用法:

  1. 一个进程创建一个自身的副本,每个副本执行各自的任务
  2. 一个进程执行另一个程序,调用fork创建一个自身的副本后,副本调用exec把自身替换成新程序

存放在硬盘上的可执行文件被Unix执行的唯一方法是:由一个现有进程调用exec函数,exec函数把当前进程映像替换成新的程序文件,而且该新程序通常从main函数开始执行,进程ID不变。

我们称调用exec的进程为_调用进程_称新执行的程序为_新程序_

exec函数有6个,其区别是:

  • 待执行的程序文件是由_文件名还是由路径名_指定
  • 新程序的参数是一一列出还是由一个指针数组来引用
  • 把调用进程的环境传递给新程序还是给新程序指定新的环境
#include <unistd.h>

//成功均不返回,出错返回-1

int execl(const char *pathname, const char *arg0, .../* (char *) 0 */);

int execv(const char *pathname, char *const *argv[]);

int execle(const char *pathname, const char *arg0, ... /* (char *) 0, char *const envp[] */);

int execve(const char *pathname, char *const argv[], char *const envp[]);

int execlp(const char *filename, const char *arg0, ... /* (char *) 0 */);

int execvp(const char *filename, char *const argv[]);

这些函数只在出错时才返回到调用者,否在,控制将被传递给新程序的起始点,通常是main函数。

execve是内核中的系统调用,其他5个都是调用execve的库函数。

1580115067308

  • 上面的三个函数以空指针结束可变数量的参数,下面三个的参数数组argv必须含有一个用于指定其末尾的空指针
  • 左列2个函数指定一个filename参数,如果filename参数里不包含斜杆(/),exec将使用当前PATH环境变量把该文件名参数转换为一个路径名
  • 左边2列4个函数不显示指定一个环境指针,使用外部变量environ的当前值来构造一个传递给新程序的环境列表。右列2个函数显示指定一个环境列表,其envp指针数组必须以一个空指针结束

4.8 并发服务器

目的:子进程处理与客户的连接,父进程则可以在监听套接字上再次调用accept来处理下一个客户连接。

//典型的并发服务器程序轮廓

pid_t pid;
int   listenfd, connfd;
listenfd = Socket( ... );

Bind(listenfd, ...);
Listen(listenfd, LISTENQ);
for( ; ; ) {
    connfd = Accept(listenfd, ... );
    if ( (pid = Fork()) == 0){
        Close(listenfd);    //子进程里关闭监听套接字
        doit(connfd);
        Close(connfd);
        //调用exit,执行进程终止处理,会关闭所有由内核打开的描述符
        exit(0);
    }
    Close(connfd);          //子进程为客户提供服务,父进程里则关闭已连接套接字
}

对一个TCP套接字调用close会导致发送一个FIN分节,随后进行正常的TCP连接终止序列。而程序中父进程对connfd调用close并没有终止它与客户的连接,原因是:每个文件或套接字都有一个引用计数,引用计算在文件表项中维护,它是当前打开着的引用该文件或套接字的描述符的个数。程序中fork后子进程共享了父进程的套接字描述符,使得套接字的引用计数加一均变为2,这样父进程close时,只是将引用计数从2减为1。该套接字真正的清理和资源释放要等到引用计数值变为0时才发生。

4.9 close函数

通常的Unix close函数也用来关闭套接字,并终止TCP连接。

#include <unistd.h>
int close(int sockfd);

close一个TCP套接字的默认行为是把该套接字标记为已关闭,然后立即返回调用进程,被关闭的套接字不能再由调用进程使用,也就是它不能再作为read或write的第一个参数。然后TCP将尝试发送已排队等待发送到对端的任何数据,发送完毕后发生正常的TCP连接终止序列。

描述符引用计数

close将导致相应描述符引用计数值减1,只要引用计数值仍大于0则不会引发TCP连接终止序列。

如果确实想在某个TCP连接上发送一个FIN,那么可以改用shutdown函数代替close。

任何进程在任何时刻可拥有的打开着的描述符数通常是有限制的。如果父进程对每个accept返回的已连接套接字都不调用close,首先父进程最终将耗尽可用描述符,接着没有一个客户连接会被终止,TCP连接终止序列也不会发生。

4.10 getsockname和getpeername函数

getsockname函数:返回与某个套接字关联的本地协议地址

getpeername函数:返回与某个套接字关联的外地协议地址

#include <sys/socket.h>

//成功返回0,出错返回-1
int getsockname(int sockfd, struct sockaddr *localaddr, socklen_t *addrlen);
int getpeername(int sockfd, struct sockaddr *peeraddr , socklen_t *addrlen);

两个函数的最后一个参数都是值-结果参数,说明两个函数都要装填由localaddr或peeraddr指针所指的套接字地址结构。

使用情况:

  • TCP客户没有调用bind,connect成功返回后,使用getsockname返回由内核赋予该连接的本地IP地址和本地端口号
  • 在以端口号0调用bind后,使用getsockname返回由内核赋予的本地端口号
  • 可用于获取某个套接字的地址族
  • 在以通配IP地址调用bind的TCP服务器上,accept成功返回后,使用getsockname返回由内核赋予该连接的本地IP地址,这里的套接字描述符参数必须是已连接套接字描述符,不是监听套接字描述符
  • 服务器某个进程accept后调用exec执行新的程序,子进程的内存映像被替换(包含端地址的那个套接字地址结构丢失),但已连接套接字的描述符跨exec继续保持开放,子进程使用函数getpeername用于获取客户的IP地址和端口号。

inetd派生服务器例子,服务器获取connfd描述符值的两种方法:

  1. 调用exec的进程可以把这个描述符号格式化成一个字符串,再把它作为一个命令行参数传递给新程序
  2. 约定在调用exec之前,总是把某个特定描述符置为所接受的已连接套接字的描述符
  3. inetd采用第二种方法,它总是把描述符0、1、2置为所接受的已连接套接字的描述符

POSIX规范允许对未绑定的套接字调用getsockname,该函数应该适合任何已打开的套接字描述符

大多数TCP服务器是并发的,大多数UDP服务器是迭代的

第5章 TCP客户/服务器程序示例

5.1 概述

image-20200212163819379

5.6 正常启动

使用netstat -a检查服务器套接字的状态

在目前众多较新的 Linux 发行版中,已经移除了 net-tools 套件,ifconfig、route、netstat、arp 等一系列工具均无法使用。

新的工具ss 代替了 netstat。

显示系统内的TCP连接,命令:ss -at

netstat用**“*”**号来表示一个为0的IP地址(INADDR_ANY,通配地址)或为0的端口号。

子进程的PPID是父进程的PID,当进程的STAT是“S”表明进程在为等待某些资源而睡眠,进程处于睡眠状态时WCHAN列指出相应的条件。

  • Linux进程阻塞于accept或connect时,输出wait_for_connect
  • 进程阻塞于套接字输入或输出时,输出tcp_data_wait
  • 在进程阻塞于终端I/O时,输出read_chan

5.7 正常终止

正常终止客户和服务器的步骤:

  1. 客户程序输入EOF,fgets返回一个空指针,str_cli函数返回
  2. str_cli函数返回到客户的main函数,main通过调用exit终止
  3. 进程终止处理的部分工作是关闭所有打开的描述符,因此客户打开的套接字有内核关闭,这导致客户TCP发送一个FIN给服务器,服务器TCP响应ACK,这是TCP连接终止的前半部分。此时服务器套接字处于CLOSE_WAIT状态,客户套接字处于FIN_WAIT_2状态
  4. 当服务器接收FIN时,服务器子进程阻塞于readline调用,readline返回0,这导致str_echo函数返回服务器子进程的main函数
  5. 服务器子进程通过调用exit来终止
  6. 服务器子进程所有描述符随之关闭,子进程关闭已连接套接字会引发TCP连接终止的最后两个分节:一个服务器到客户的FIN和一个客户到服务器的ACK。至此,连接完全终止,客户套接字进入TIME_WAIT状态
  7. 进程终止处理的另一个部分内容是:在服务器子程序终止时,给父进程发送一个SIGCHLD信号。

5.8 POSIX信号处理

信号就是告知某个进程发生了某个事件的通知,有时也称软件中断

信号通常是异步发生的,也就是说进程预先不知道信号的准确发生时刻。

信号可以:

  • 由一个进程发送给另一个进程(或自身)
  • 由内核发给某个进程

每个信号都有一个与之关联的处置(disposition),也称为行为(action),我们通常通过调用sigaction函数来设定一个信号的处置,并有三种选择:

  1. 提供一个函数,只要有特定信号发生就被调用,这样的函数称为信号处理函数(signal handler),这种行为称为**捕获(catching)**信号。有两个信号不能被捕获:SIGKILL 和 SIGSTOP。信号处理函数由信号值这个单一的整数参数来调用,没有返回值。

    void handler(int signo);
    
  2. 把某个信号的处置设置为SIG_IGN来忽略它,SIGKILL 和 SIGSTOP不能被忽略。

  3. 把某个信号的处置设定为SIG_DEF来启用它的默认处置。默认处置通常是在收到信号后终止进程,其中某些信号还在当前工作目录产生一个进程的核心映像(core image,也称内存映像)。个别信号的默认处置是忽略,例如SIGCHLD 和 SIGURG。

signal函数

建立信号处置的POSIX方法就是调用sigaction函数,但是有点复杂,因为该函数的参数之一是我们必须分配并填写的结构。简答些的方法是调用signal函数,其第一个参数是信号名,第二个参数或为指向函数的指针,或为常值SIG_IGN或SIG_DFL。

signal函数是早于POSIX出现的历史悠久的函数,不同的实现提供不同的信号语义以达成后向兼容。

POSIX则明确规定了调用sigaction时的信号语义。我们定义了自己的signal函数,它只是调用POSIX的sigaction函数,这就以所期望的POSIX语义提供一个简答的接口。

image-20200212205315625

image-20200212210809188

POSIX信号语义

  • 一旦安装了信号处理函数,便一直安装着(早期系统执行一次便拆除)
  • 在一个信号处理函数运行期间,正被递交的信号是阻塞的,而且sa_mask信号集中指定的任何额外信号也被阻塞
  • 如果一个信号在被阻塞期间产生了一次或多次,那么该信号被解阻塞之后通常只递交一次,也就是说Unix信号默认是不排队的
  • 利用sigprocmask函数选择性地阻塞或解阻塞一组信号是可能的。可以做到在临界区代码执行期间,防止捕获某些信号,以此保护这段代码

5.9 处理SIGCHLD信号

设置僵死(zombie)状态的目的是维护子进程的信息(子进程的进程ID、终止状态以及资源利用信息(CPU时间、内存使用量等等)),以便父进程在以后某个时候获取。

如果一个进程终止,而该进程有子进程处于僵死状态,那么它的所有僵死子进程的父进程ID将重置为1(init进程)。继承这些子进程的init进程将清理它们。

处理僵死进程

僵死进程占用内核中的空间,可能导致耗尽进程资源。

无论何时我们fork子进程都得wait它们,防止它们变成僵死进程,为此我们建立一个捕获SIGCHLD信号的信号处理函数,在函数体中调用wait。

Signal(SIGCHLD, sig_chld);

void sig_child(int signo){
    pid_t    pid;
    int      stat;
    
    pid = wait(&stat);
    //在信号处理函数中调用诸如printf这样的标准I/O函数是不合适的
    printf("child %d terminated\n", pid);
    return;
}

处理被中断的系统调用

慢系统调用适用于那些可能永远阻塞的系统调用,永远阻塞的系统调用是指调用有可能永远无法返回,多数网络支持函数都属于这一类。

适用于慢系统调用的基本规则:当阻塞于某个慢系统调用的一个进程捕获某个信号且相应信号处理函数返回时,该系统调用可能返回一个EINTR错误(被中断的系统调用)。有些内核自动重启某些被中断的系统调用。

由于“可能”、“有些”以及对POSIX的SA_RESTART标志的支持是可选的,我们必须考虑可移植性问题。为了便于移植,我们编写捕获信号的程序时(多数并发服务器捕获SIGCHLD),我们必须对慢系统调用返回EINTR有所准备。

for( ; ; ){
    client = sizeof(cliaddr);
    if((connfd = accept(listenfd, (SA *) &cliaddr, &clinet)) < 0 ){
        if(errno == EINTR)
            continue;
        else
            err_sys("accept error");
    }
}

我们可以编写函数,自己重启被中断的系统调用,如accept、read、write、select和open之类的函数,

但是connect函数我们不能重启:如果该函数返回EINTR,我们就不能再次调用它,否则将立即返回一个错误,当connect被一个捕获的信号中断而且不自动重启时,我们必须调用select来等待连接完成。

5.10 wait和waitpid

#include <sys/wait.h>

//成功返回进程ID,出错返回0或-1
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);

函数wait和waitpid均返回两个值

  1. 已终止子进程的进程ID号
  2. 通过statloc指针返回子进程终止状态(一个整数)

调用wait的进程没有已终止的子进程,不过有一个或多个子进程仍在执行,那么wait将阻塞到现有子进程第一个终止为止。

waitpid函数就等待哪个进程终止以及是否阻塞给了我们更多的控制:

  1. pid参数允许我们指定想等待的进程ID,值-1表示等待第一个终止的子进程
  2. options参数允许我们指定附加选项,常用选项是WNOHANG,告知内核在没有已终止子进程时不要阻塞。

函数wait和waitpid的区别

若有多个子进程同时终止,则同一时刻有5个SIGCHLD信号递交给父进程,因为Unix信号一般是不排序的,导致调用wait的信号处理函数只执行了一次。而使用waitpid函数,则可以在一个循环内调用(无法防止wait在正在运行的子进程尚有未终止时阻塞,不能在循环内调用wait),以获取所有已终止子进程的状态,这时必须指定WNOHANG选项,告知waitpid在尚有未终止的子进程在运行时不阻塞。

本节的目的是示范我们在网络编程时可能会遇到的三种情况:

  1. 当fork子进程时,必须捕获SIGCHLD信号
  2. 当捕获信号时,必须处理被中断的系统调用
  3. SIGCHLD的信号处理函数必须正确编写,应使用waitpid函数以免留下僵死进程
//处理accept返回EINIT的TCP服务器程序最终(正确)版
#include	"unp.h"

int
main(int argc, char **argv)
{
	int					listenfd, connfd;
	pid_t				childpid;
	socklen_t			clilen;
	struct sockaddr_in	cliaddr, servaddr;
	void				sig_chld(int);

	listenfd = Socket(AF_INET, SOCK_STREAM, 0);

	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family      = AF_INET;
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
	servaddr.sin_port        = htons(SERV_PORT);

	Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));

	Listen(listenfd, LISTENQ);

	Signal(SIGCHLD, sig_chld);	/* must call waitpid() */

	for ( ; ; ) {
		clilen = sizeof(cliaddr);
		if ( (connfd = accept(listenfd, (SA *) &cliaddr, &clilen)) < 0) {
			if (errno == EINTR)
				continue;		/* back to for() */
			else
				err_sys("accept error");
		}

		if ( (childpid = Fork()) == 0) {	/* child process */
			Close(listenfd);	/* close listening socket */
			str_echo(connfd);	/* process the request */
			exit(0);
		}
		Close(connfd);			/* parent closes connected socket */
	}
}

5.11 accept返回前连接中止

三次握手完成建立连接后,客户TCP却发送RST,服务器端该连接已由TCP排队,在等待accept时RST到达,稍后服务器进程调用accept。

image-20200213115625378

处理连接中止依赖于不同的实现,大多数返回一个错误给服务器,作为accept的返回结果,POSIX指出返回的errno值必须是ECONNABORTED(“software caused connection abort”,软件引起的连接中止)。遇到此错误,服务器可以选择忽略它,再次调用accept函数。

5.12 服务器进程终止

启动客户/服务器,然后杀死服务器子进程,模拟服务器进程崩溃时的情况:

  1. 于同一台主机启动客户和服务器,验证一切正常

  2. 找到服务器子进程ID,kill掉它。进程终止的处理工作会关闭子进程中所有打开着的描述符,导致向客户发送一个FIN,而客户响应一个ACK(TCP连接终止工作的前半部分)

  3. SIGHELD信号发送给服务器父进程,并得到正确处理

  4. 客户接受到FIN并响应ACK的时候,正阻塞在fgets调用上,等待终端接收一个文本

  5. 当客户在终端上输入文本时,将调用writen,客户TCP将把数据发送给服务器,当服务器接收到数据后,由于先前打开的描述符已经终止,于是响应一个RST

    客户TCP接收到FIN只是表示服务器进程已关闭了连接的服务器端,从而(服务器)不再往其发送任何数据。FIN的接收并没有告知客户TCP服务器进程已经终止。
    
  6. 而客户进程看不到这个RST,因为调用writen后立即调用readline,并由于步骤2接收的FIN,readline立即返回0(EOF)。客户此时未预期收到EOF,于是返回出错信息“server terminated prematurely”(服务器过早终止)退出

  7. 客户终止,所有打开着的描述符都被关闭

5.13 SIGPIPE信号

当一个进程向某个已收到RST的套接字执行写操作时,内核向该进程发送一个SIGPIPE信号。该信号默认行为是终止进程,因此进程必须捕获它以免不情愿地被终止。

无论进程是捕获了该信号并从其信号处理函数返回,还是简答地忽略该信号,写操作都将返回EPIPE错误

如果不理会readline函数返回的错误,反而写更多的数据到服务器上,客户在读回任何数据之前执行两次对服务器的写操作,第一次写操作引发RST,第二次写操作引发SIGPIPE信号。

写一个已接受了FIN的套接字不成问题,但是写一个接收了RST的套接字则是一个错误。

处理SIGPIPE的建议方法取决于它发生时应用进程想做什么,如果没有特殊的事情要做,那么将信号处理方法直接设置为SIG_IGN,并假设后续的输出操作将捕捉EPIPE错误并终止。

如果使用多个套接字,该信号的提交无法告诉我们是哪个套接字出的错,如果确实需要知道是哪个write出了错,那么要么不理会该信号,要么从信号处理函数返回后再处理来自write的PIPE。

5.14 服务器主机崩溃

  1. 当服务器主机崩溃时,已有的网络连接上不发出任何东西
  2. 当客户键入文本,由writen写入内核,再由客户TCP作为一个数据分节送出,阻塞于readline,等待回射应答
  3. 客户TCP将持续重传数据分节,试图从服务器上接收一个ACK。若服务器已崩溃,从而对客户的数据分节根本没有响应,那么返回的错误是ETIMEOUT,然而如果某个中间的路由器判定服务器已不可到达,从而响应一个“destination unreachable”(目的地不可到达)ICMP信息,那么返回的错误是EHOSTUNREACH或ENETHNREACH

通过设置超时可以及时检测出不可到达的情况。如果不主动向它发送数据也想检测出服务器主机的崩溃,那么就需要SO_KEEPALIVE套接字选项。

5.15 服务器主机崩溃后重启

  1. 当服务器主机崩溃重启后,它的TCP丢失了崩溃前的所有连接信息,因此服务器TCP对于收到的来自客户的数据分节响应以一个RST。
  2. 客户TCP收到RST时,客户正阻塞于readline调用,导致该调用返回ECONNRESET错误。

5.16 服务器主机关机

Unix系统关机时,init进程通常先给所有进程发送SIGTERM信号(该信号可被捕获),等待一段固定的时间(往往在5到20秒之间),然后给所有仍在运行的进程发送SIGKILL信号(该信号不可被捕获)。这么做留个所有运行的进程一小段时间来清除和终止。

  • 如果我们忽略SIGTERM信号,我们的服务器将由SIGKILL信号终止
  • 如果我们不捕获也不忽略SIGTERM信号,那么起作用的是SIGTERM的默认处置(终止进程),那么服务器将被SIGTERM信号终止,SIGKILL信号不可能再发送给服务器

5.17 TCP程序例子小结

从客户角度总结TCP客户/服务器

image-20200218171852426

从服务器角度总结TCP客户/服务器

image-20200218172026706

5.18 数据格式

传递二进制数据可能存在的问题:

  1. 不同的实现以不同的格式存储二进制数,大端字节序和小端字节序
  2. 不同的实现在存储相同的C数据类型上可能存在差异,32位系统使用32位表示long,64位系统使用64位
  3. 不同的实现给结构打包的方式存在差异,取决于各种数据类型所使用的位数以及机器的对齐限制

处理数据格式问题常用方法:

  1. 把所有的数值数据作为文本串来传递(客户和服务器主机具有相同的字符集)
  2. 显示定义所支持数据类型的二进制格式(数位、大端或小端字节序),并以这样的格式在客户与服务器之间传递所有数据,远程过程调用(RPC)软件包通常使用这种技术

第6章 I/O复用:select和poll函数

6.1 概述

内核一旦发现进程指定的一个或多个I/O条件就绪(也就是说输入已准备好被读取,或者描述符已能承接更多的输出),它就通知进程,这个能力称为I/O复用。

I/O复用由select和poll两个函数支持,前者较新的称为pselect的POSIX变种。

I/O复用并非只限于网络编程,I/O复用典型使用在下列网络应用场合:

  • 当客户处理多个描述符(通常是交互式输入和网络套接字)时,必须使用I/O复用
  • 一个客户同时处理多个套接字是可能的,不过比较少见
  • 如果一个TCP服务器既要处理监听套接字,又要处理已连接套接字,一般就要使用I/O复用
  • 如果一个服务器既要处理TCP,又要处理UDP,一般就要使用I/O复用
  • 如果一个服务器要处理多个服务或者多个协议,一般就要使用I/O复用

6.2 I/O模型

Unix下可用的I/O模型有五种:

  • 阻塞式I/O
  • 非阻塞式I/O
  • I/O复用(select和poll)
  • 信号驱动式I/O(SIGIO)
  • 异步I/O(POSIX的aio_系列函数)

一个输入操作通常包括两个不同的阶段:

  1. 等待数据准备好
  2. 从内核向进程复制数据

对于一个套接字上的输入操作:

  1. 第一步通常涉及等待数据从网络中到达,当所等待的分组到达时,它被复制到内核中的某个缓冲区
  2. 把数据从内核缓冲区复制到应用进程缓冲区

6.2.1 阻塞式I/O阻塞

最流行的I/O模型是阻塞式I/O模型。默认情况下,所有套接字都是阻塞的。

image-20200220142525476

6.2.2 非阻塞式I/O模型

进程把一个套接字设置为非阻塞是在通知内核:当所请求的I/O操作非把本进程投入睡眠才能完成时,不要把本进程投入睡眠,而是返回一个错误。

image-20200220143123892

轮询(polling):应用进程持续轮询内核,以查看某个操作是否就绪,这样做往往耗费大量CPU时间。

6.2.3 I/O复用模型

通过调用select或poll,阻塞在这两个系统调用中的某一个之上,而不是阻塞在真正的I/O调用上

image-20200220211237813

I/O复用需要两个系统调用

优势:可以等待多个描述符就绪

6.2.4 信号驱动式I/O模型

使用信号,让内核在描述符就绪时发送SIGIO信号通知我们,这种模式为信号驱动式I/O。

image-20200220211730610

优势:等待数据报到达期间进程不被阻塞,主循环可以继续执行,只要等待来自信号处理函数的通知:既可以是数据已经准备好被处理,也可以是数据报已准备好被读取

6.2.5 异步I/O模型

异步I/O由POSIX规范定义。

异步函数的工作机制是:告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。

与信号驱动模型的主要区别在于:信号驱动式I/O是由内核通知我们何时可以启动一个I/O操作,而异步I/O模型是由内核通知我们I/O操作何时完成。

image-20200220222137004

POSIX异步函数以aio_或lio_开头。

这里调用aio_read函数,给内核传递描述符、缓冲区指针、缓冲区大小(与read相同的三个参数)和文件偏移(与lseek类似),并告诉内核当整个操作完成时如何通知我们。该系统调用立即返回,而且在等待I/O完成期间,进程不被阻塞。

6.2.6 各种I/O模型的比较

POSIX定义:

  • 同步I/O操作:导致请求进程阻塞,知道I/O操作完成

  • 异步I/O操作:不导致请求进程阻塞

image-20200220222618152

前4种模型都是同步I/O模型,因为其中真正的I/O操作(recvfrom)将进程阻塞。只有异步I/O模型与POSIX定义的异步I/O相匹配。

6.3 select函数

该函数允许进程指示内核等待多个事件中的任何一个发送,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒它。

select函数告知内核对哪些描述符(就读、写或异常条件)感兴趣以及等待多长时间。描述符不局限于套接字,任何描述符都可以使用select来测试。

#include <sys/select.h>
#include <sys/time.h>

//若有就绪描述符则返回其数目,若超时则返回0,若出错则返回-1
int select(int maxfdp1, fd_set *readset, fd_set *writeset, 
           fd_set *exceptset, const struct timeval *timeout);

参数解析:

  1. timeout:告知内核等待所指定描述符中的任何一个就绪可花多长时间

    struct timeval{
        long     tv_sec;    //秒数
        long     tv_usec;   //微秒数
    }
    

    该参数有三种情况:

    • 永远等待下去:仅在有一个描述符准备好I/O时才返回,此时将该参数设置为空指针
    • 等待一段固定时间:在有一个描述符准备好I/O时返回,但是不超过由该参数所指向的timeval结构中指定的秒数和微秒数
    • 根本不等待:检测描述符后立即返回,这称为轮询。该参数指向timeval结构,其中定时器的值必须为0

    前两种情况的等待通常会被进程在等待期间捕获的信号中断,并从信号处理函数返回。

    timeval结构允许我们指定一个微秒级的分辨率,然而内核支持的真实分辨率往往粗糙得多,许多Unix内核把超时值向上舍入成10ms的倍数,另外内核还需要额外的调度延迟。

    timeout参数的const限定词表示它在函数返回时不会被select修改,无法通过该参数计算出实际等待时间。

  2. 中间三个参数readset、writeset和exceptset指定我们要让内核测试读、写和异常条件的描述符

    • 目前支持的异常条件只有两个:

      1. 某个套接字的带外数据的到达
      2. 某个已置为分组模式的伪终端存在可从其主端读取的控制状态信息
    • select使用描述符集来给3个参数中的每一个参数指定一个或多个描述符值,通常是一个整数数组,其中每个整数中的每一位对应一个描述符。具体实现与应用程序无关,隐藏在数据类型fd_set和以下四个宏中:

      void FD_ZERO(fd_set *fdset);		//clear all bits of fdset
      void FD_SET(int fd, fd_set *fdset);	//turn on the bit for fd in fdset
      void FD_CLR(int fd, fd_set *fdset);	//turn off the bit for fd in fdset
      void FD_ISSET(int fd, fd_set *fdset);//is the bit for fd on fdset ?
      

      我们分配一个fd_set类型的描述符集,并用这些宏设置和测试集合中的每一位,也可以使用赋值语句将它赋值成另一个描述符集:

      fd_set rset;
      
      FD_ZERO(&rset);	//初始化
      FD_SET(1, &rset);
      FD_SET(4, &rset);
      FD_SET(5, &rset);
      
    1. maxfdpl参数指定待测试的描述符的个数,它的值是待测试的最大描述符加1,描述符0,1,2……一直到maxfdpl-1均将被测试
    2. 头文件<sys/select.h>中定义的FD_SETSIZE常值是数据类型fd_set中描述符总数,通常是1024,通常使用不了那么多
    3. select函数修改由指针readset、writeset和exceptset所指向的描述符集,因此这三个参数都是值-结果传参。函数返回时,指示哪些描述符已就绪,描述符集内任何与未就绪描述符对应的位返回时均清零,因此每次重新调用select函数时,都需要重新为关心的位均置1。

6.3.1 描述符就绪条件

满足下列条件之一,则套接字准备好读:

  1. 该套接字的接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记SO_RCVLOWAT的当前大小,对该套接字读操作不阻塞并返回一个大于0的值
  2. 该连接的读半部关闭(也就是接收了FIN的TCP连接),读操作不阻塞并返回0
  3. 该套接字是一个监听套接字,且已完成的连接数不为0,不阻塞
  4. 其上有一个套接字错误待处理,不阻塞并返回-1,同时把设置errno为错误条件。此时可以使用getsockopt来读取和清除该错误

满足下列条件之一,则套接字准备好写:

  1. 该套接字的发送缓冲区中的可用空间字节数大于等于套接字发送缓冲区低水位标记的当前大小,并且该套接字已经连接或者不需要连接(如UDP套接字),对该套接字写操作不阻塞并返回一个大于0的值
  2. 该连接的写半部关闭,对这样的套接字的写操作将产生SIGPIPE信号
  3. 使用非阻塞式connect的套接字已建立连接,或者connect已经以失败告终
  4. 其上有一个套接字错误待处理,不阻塞并返回-1,同时把设置errno为错误条件

如果一个套接字存在带外数据或者仍处于带外标记,那么它有异常条件待处理。当某个套接字上发生错误时,它将由select标记为既可读又可写。

接收低水位标记和发送低水位标记的目的在于:允许应用进程控制在select返回可读或可写条件之前有多少数据可读或有多大空间可用于写。

举例:当数据少于64字节时,应用程序没有任何有效工作可做,则把接收低水位标记设置为64,以防少于64字节的数据准备好时select唤醒程序。

任何UDP套接字只要其发送低水位标记小于等于发送缓冲区大小(默认关系)就总是可写的,这是因为UDP套接字不需要连接。

image-20200805213132250

6.3.2 select的最大描述符数

最初设计select时,操作系统通常对每个进程可用的最大描述符数设置了上限,select就使用了相同的限制。

当今的Unix版本允许每个进程使用事实上无数目限制的描述符(往往仅受限于内存总量和管理性限制)。

表面上可以通过将FD_SETSIZE定义为某个更大的值,实际上却行不通,首先它是内核集成的,修改后需要重新编译内核,其次可能存在扩展性问题。

有些应用程序开始改用poll代替select,典型例子是需要复选大量描述符的事件驱动型服务器程序,所需描述符量超过1024个

6.4 str_cli函数(修订版)

原先版本可能阻塞于fgets调用,新版改为阻塞于select调用,或是等待标准输入可读,或是等待套接字可读。

image-20200805220011258

客户的套接字上的三个条件处理如下:

  • 如果对端TCP发送数据,那么该套接字变为可读,并且read返回一个大于0的值(读入数据的字节数)
  • 如果对端TCP发送一个FIN(对端进程终止),那么该套接字变为可读,并且read返回0(EOF)
  • 如果对端TCP发送一个RST(对端主机崩溃并重新启动),那么该套接字变为可读,并且read返回-1,而errno中含有确切的错误码
#include "unp.h"

void
str_cli(FILE *fp, int sockfd){
    int 	maxfdpl;
    fd_set	rset;
    char	sendline[MAXLINE], recvline[MAXLINE];
    
    FD_ZERO(&rset);
    for( ; ; ){
        FD_SET(fileno(fp), &rset);
        FD_SET(sockfd, &rset);
        //fileno函数把标准I/O文件指针转换为对应的描述符
        maxfdpl = max(fileno(fp), sockfd) + 1;
        Select(maxfdpl, &rset, NULL, NULL, NULL);
        
        if(FD_ISSET(sockfd, &rset)){		//socket is readable
            if(Readline(sockfd, recvline, MAXLINE) == 0)
                err_quit("str_cli: server terminated prematurely");
            Fputs(recvline, stdout);
        }
        
        if(FD_ISSET(fileno(fp), &rset)){	//input is readable
            if(Fgets(sendline, MAXLINE, fp) == NULL)
                return;
            Writen(sockfd, sendline, strlen(sendline));
        }
    }
}

6.5 批量输入

当客户端使用停-等方式工作时,虽然对交互式使用是合适的,但是却不能实现对通信管道的高效利用。

如果把客户与服务器之间的网络作为全双工管道考虑,请求从客户想服务器发送,应答从服务器向客户发送,则停-等方式如下图:

image-20200806100510176

在Unix的shell环境下,很容易实现重定向标准输入和标准输出,从而可以批量运行客户。当我们把标准输入和标准输出重定向到文件来运行新的客户程序时,却发现输出文件总是小于输入文件(对于回射服务器而言理应相等)。

image-20200806101504809

当运行一个客户程序时,标准输入的EOF同时意味着完成从套接字的读入。批量运行客户程序时,客户程序写完请求时,并不能立即关闭连接,因为管道中还有其它的请求和应答,但是修订版的str_cli函数对标准输入EOF的处理却是返回到main函数,而main函数随后终止。

我们需要的是一种关闭TCP连接其中一半的方法,即给服务器发送一个FIN告诉它我们已经完成了数据发送,但仍然保持套接字描述符打开以便读取。由shutdown函数完成。

为了提升性能而引入的缓冲机制增加了网络应用程序的复杂性:

  • fgets读取输入,将数据存放在stdio缓冲区,但是fgets只返回其中一行,其余仍在缓冲区。select处理完一行后再次被调用等待新的工作,并不知道stdio使用了缓冲区——它只是从read系统的角度指出是否有数据可读,而不是从fgets之类的调用角度

  • readline调用时,select不可见的数据隐藏在readline自己的缓冲区中

6.6 shutdown函数

终止网络连接的通常方法是调用close函数,不过close函数有两个限制,却可以使用shutdown来避免:

  • close把描述符引用计数减1,仅在计数变为0时才关闭套接字。shutdown可以不管引用计数就激发TCP的正常连接终止序列
  • close终止读和写两个方向的数据传送。既然TCP连接是全双工的,有时候我们需要告知对端已经完成数据发送,即使对端仍有数据要发送给我们。

image-20200806105214830

#include <sys/socket.h>
//成功返回0,出错返回-1
int shutdown(int sockfd, int howto);

函数行为依赖于howto参数的值:

  • SHUT_RD:关闭连接的读一半——套接字中不再有数据可接收,套接字接收缓冲区中的现有数据都被丢弃。进程不能再对套接字调用任何读函数。TCP套接字调用shutdown函数后,由该套接字接收的来自对端的任何数据都将被确认,然后悄然丢弃
  • SHUT_WR:关闭连接的写一半——对于TCP套接字,这称为半关闭。当前留在套接字发送缓冲区的数据将被发送掉,后跟TCP正常连接终止序列。不能再对套接字调用任何写函数
  • SHUT_RDWR:连接的读半部和写半部都关闭——等效于调用两次shutdown,第一次指定SHUT_RD,第二次指定SHUT_WR

这三个SHUT_XXX名字由POSIX规范定义,howto参数的典型值将会是0(关闭读半部)、1(关闭写半部)和2(读半部和写半部都关闭)。

6.7 str_cli函数(再修订版)

改进(且正确)版本:

  • 服务器一关闭它那一端的连接立马得到通知
  • 可以正确处理批量输入
  • 废弃以文本行为中心,改而针对缓冲区操作
#include "unp.h"

void
str_cli(FILE *fp, int sockfd){
    int 	maxfdpl, stdineof;
    fd_set	rset;
    char	buf[MAXLINE];
    int n;
    
    stdineof = 0;
    FD_ZERO(&rset);
    for( ; ; ){
        if(stdineof == 0)
            FD_SET(fileno(fp), &rset);
        FD_SET(sockfd, &rset);
        //fileno函数把标准I/O文件指针转换为对应的描述符
        maxfdpl = max(fileno(fp), sockfd) + 1;
        Select(maxfdpl, &rset, NULL, NULL, NULL);
        
        if(FD_ISSET(sockfd, &rset)){		//socket is readable
            if( (n = Read(sockfd, buf, MAXLINE)) == 0){
                if(stdineof == 1)
                    return;
                else
                    err_quit("str_cli: server terminated prematurely");
            }
            Write(fileno(stdout), buf, n)
        }
        
        if(FD_ISSET(fileno(fp), &rset)){	//input is readable
            if( (n = Read(fileno(fp), buf, MAXLINE)) == 0){
                stdineof = 1;
                Shutdown(sockfd, SHUT_WR);
                FD_CLR(fileno(fp), &rset);
                continue;
            }
            Writen(sockfd, buf, n);
        }
    }
}

6.8 TCP回射服务器程序(修订版)

使用select来处理任意个客户的单进程程序,而不是为每个客户派生一个子进程。

#include "unp.h"

int
main(int argc, char **argv){
    int		i, maxi, maxfd, listenfd, connfd, sockfd;
    //服务器所能处理的最大客户数目的限制是:
    //	min[FD_SETSIZE, 内核允许本进程打开的最大描述符数]
    //进程能打开的描述符数数目上已经无限制,只受资源和内存限制
    int		nready, client[FD_SETSIZE];
    ssize_t	n;
    fd_set	rset, allset;
    char	buf[MAXLINE];
    socklen_t	clilen;
    struct sockaddr_in cliaddr, servaddr;
    
    listenfd = Socket(AF_INET, SOCK_STREAM, 0);
    
    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(SERV_PORT);
    
    Bind(listenfd, (SA*)&servaddr, sizeof(servaddr));
    
    Listen(listenfd, LISTENQ);
    
    //描述符集前三位分别被设置为:标准输入、标准输出和标准错误输出
    //select第一个参数为:maxfd+1
    maxfd = listenfd;
    maxi = -1;
    for(i = 0; i < FD_SETSIZE; i++)
        client[i] = -1;
    FD_ZERO(&allset);
    FD_SET(listenfd, &allset);
    
    for( ; ; ){
        rset = allset;
        nready = Select(maxfd+1, &rset, NULL, NULL, NULL);
        
        if(FD_ISSET(listenfd, &rset)){
            clilen = sizeof(cliaddr);
            connfd = Accept(listenfd, (SA*) &cliaddr, &clilen);
            
            for(i = 0; i < FD_SETSIZE; i++){
                if(client[i] < 0){
                    client[i] = connfd;
                    break;
                }
            }
            if(i == FD_SETSIZE)
                err_quit("too many clients");
            FD_SET(connfd, &allset);
            if(connfd > maxfd)
                maxfd = connfd;
            if(i > maxi)
                maxi = i;
            if(--nready <= 0)
                continue;
        }
        for(i = 0; i <= maxi; i++){
            if((sockfd = client[i]) < 0)
                continue;
            if(FD_ISSET(sockfd, &rset)){
                if((n = Read(sockfd, buf, MAXLINE)) == 0){
                    Close(sockfd);
                    FD_CLR(sockfd, &allset);
                    client[i] = -1;
                }else
                    Writen(sockfd, buf, n);
                if(--nready <= 0)
                    break;
            }
        }
    }
}

面向文本行服务器程序存在一个问题:如果一个恶意的客户连接到服务器,发送一个字节的数据(不是换行符)后进入睡眠,服务器将会调用readline从客户读入这个单字节的数据,然后阻塞于下一个read(readline内部的read)调用,等待客户其它数据。服务器因此阻塞而不能再为其它客户提供服务,直到那个恶意的客户发出一个换行符或终止为止。

当前版本的服务器程序已经弃用面向文本行的方法,等待换行输入或EOF而引起的拒绝服务攻击已经不复存在。

拒绝服务型攻击:当一个服务器在处理多个客户时,它绝对不能阻塞于只与单个客户相关的某个函数调用。否则可能导致服务器被挂起,拒绝为所有其它客户提供服务。

可能的解决办法:

  • 使用非阻塞式I/O
  • 让每个客户由单独的控制线程提供服务
  • 对I/O操作设置一个超时

6.9 pselect函数

pselect函数是由POSIX发明的,如今许多Unix变种支持它

#include <sys/select.h>
#include <signal.h>
#include <time.h>
//若有就绪描述符则返回其数目,超时返回0,出错返回-1
int pselect(int maxfdpl, fd_set *readset, 
            fd_set *writeset, fd_set *exceptset,
            const struct timespec *timeout, const sigset_t *sigmark);

pselect相对于通常的select有两个变化:

  • pselect使用timespec结构(POSIX的一个发明),不使用timeval结构

    struct timespec{
        time_t	tv_sec;
        //新结构第二个成员指定纳秒数,旧结构指定微秒数
        long	tv_nsec;
    };
    
  • pselect函数增加了第六个参数:一个指向信号掩码的指针。该参数允许程序先禁止递交某些信号,再测试由这些当前被禁止信号的信号处理函数设置的全局变量,然后调用pselect,然后告诉它重新设置信号掩码

6.10 poll函数

poll函数起源于SVR3,最初局限于流设备,SVR4取消了这种限制,允许poll工作在任何描述符上。

poll提供的功能与select类似,不过在处理流设备时,它能够提供额外的信息。

#include <poll.h>

struct pollfd{
    int		fd;
    short	events;	//指定要测试的条件
    short	revents;//返回描述符的状态
}
//若有就绪描述符返回其数目,超时返回0,出错返回-1
int poll(struct pollfd *fdarray, unsigned long nfds, int timeout);

image-20210122225638690

图分为三个部分:

  • 第一部分:处理输入的四个常值
  • 第二部分:处理输出的三个常值
  • 第三部分:处理错误的三个常值,不能在events中设置,但是当相应条件存在时就在revents中返回

poll识别三类数据:普通(normal)、优先级带(priority band)、高优先级(high priority)

就TCP和UDP套接字而言,以下条件引起poll返回特定的revent。不幸的是,POSIX在其poll的实现中留了许多空洞(即有多种方法可返回相同的条件):

  • 所有正规的TCP数据和所有UDP数据都被认为是普通数据
  • TCP的带外数据被认为是优先级带数据
  • 当TCP连接的读半部关闭时,也被认为是普通数据,随后读操作返回0
  • TCP连接存在错误既可以认为是普通数据,也可以是错误(POLLERR)。读操作后都将返回-1,并设置errno,可用于收到RST或发生超时等条件
  • 在监听套接字上有新的连接可用既可以认为是普通数据,也可以认为是优先级数据。大多数实现视为普通数据
  • 非阻塞connect的完成被认为是使相应套接字可写

参数nfds指定结构数组中元素的个数;timeout参数指定poll函数返回前等待多长时间,它是一个指定应等待毫秒数的正值。

image-20200806153002725

INFTIM常值被定义为一个负值,如果系统不能提供毫秒级精度的定时器,该值就向上舍入到最接近的支持值。

两种方法用来(也许只是暂时的)关闭对单个文件描述符的检查,而不需要重新建立整个fds列表:

  • 将events设为0
  • 如果不再关心某个特定描述符,那么可以把与它对应的pollfd结构的fd成员设置成一个负值,poll函数将忽略这样的pollfd结构的events成员,返回时将它的revents成员的值设置为0。

6.11 TCP回射服务器程序(再修订版)

#include "unp.h"
#include <limits.h>	//for OPEN_MAX

int
main(int argc, char **argv){
    int		i, maxi, listenfd, connfd, sockfd;
    int		nready;
    ssize_t	n;
    char	buf[MAXLINE];
    socklen_t	clilen;
    struct pollfd client[OPEN_MAX];
    struct sockaddr_in cliaddr, servaddr;
    
    listenfd = Socket(AF_INET, SOCK_STREAM, 0);
    
    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(SERV_PORT);
    
    Bind(listenfd, (SA*)&servaddr, sizeof(servaddr));
    
    Listen(listenfd, LISTENQ);
    
    client[0].fd = listenfd;
    client[1].events = POLLRDNORM;
    for(i = 1; i < OPEN_MAX; i++)
        client[i].fd = -1;
    maxi = 0;	//含义client数组当前正在使用的最大下标值
    
    for( ; ; ){
        nready = Poll(client, maxi + 1, INFTIM);
        
        if(client[0].revents & POLLRDNORM){
            clilen = sizeof(cliaddr);
            connfd = Accept(listenfd, (SA*) &cliaddr, &clilen);
            
            for(i = 1; i < OPEN_MAX; i++){
                if(client[i].fd < 0){
                    client[i].fd = connfd;
                    break;
                }
            }
            if(i == OPEN_MAX)
                err_quit("too many clients");
            client[i].events = POLLRDNORM;
            if(i > maxfi)
                maxfi = i;
            if(i > maxi)
                maxi = i;
            if(--nready <= 0)
                continue;
        }
        for(i = 1; i <= maxi; i++){
            if((sockfd = client[i].fd) < 0)
                continue;
            if(client[i].revents & (POLLRDNORM | POLLERR)){
                if((n = Read(sockfd, buf, MAXLINE)) < 0){
                    if(errno == ECONNRESET){
                        Close(sockfd);
                        client[i].fd = -1;
                    }else
                        err_SYS("read error");
                }else if(n == 0){
                    Close(sockfd);
                    client[i].fd = -1;
                }else
                    Writen(sockfd, buf, n);
                if(--nready <= 0)
                    break;
            }
        }
    }
}

第7章 套接字选项

7.1 概述

获取和设置套接字选项的方法:

  • getsockopt和setsockopt函数
  • fcntl函数
  • ioctl函数

7.2 getsockopt和setsockopt函数

这两个函数仅用于套接字:

#include <sys/socket.h>
//若成功都返回0,出错都返回-1
int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen);
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
  • sockfd:指向一个打开的套接字
  • level:指定系统中解释选项的代码,为通用套接字代码或某个特定于协议的代码(IPv4、IPv6、TCP、SCTP)
  • optval:指向某个变量(*optval)的指针,大小由最后一个参数指定
    • setsockopt从中取得选项待设置的新值,是一个值参数
    • getsockopt把已获取的选项当前值存放到*optval中,是一个值-结果参数

套接字选项粗分为两大基本类型:

  • 启用或禁止某个特性的二元选项(称为标志选项)
    • getsockopt:*optval是一个整数,返回的值为0表示相应选项被禁止,不为0表示相应选项被启用
    • setsockopt:*optval是一个整数,不为0的值表示启用选项,为0的值表示禁止选项
  • 取得并返回我们可以设置或检查的特定值的选项(称为值选项)

7.3 检查选项是否受支持并获取默认值

/* include checkopts1 */
/* *INDENT-OFF* */
#include    "unp.h"
#include    <netinet/tcp.h>        /* for TCP_xxx defines */

//getsockopt的每个可能的返回值,union类型中都有一个成员
union val {
  int                i_val;
  long                l_val;
  struct linger        linger_val;
  struct timeval    timeval_val;
} val;

//用于输出给定套接字选项的值的4个函数的原型
static char    *sock_str_flag(union val *, int);
static char    *sock_str_int(union val *, int);
static char    *sock_str_linger(union val *, int);
static char    *sock_str_timeval(union val *, int);

//定义结构体,声明并定义结构体数组
struct sock_opts {
  const char       *opt_str;
  int        opt_level;
  int        opt_name;
  char   *(*opt_val_str)(union val *, int);    //函数指针,指向输出函数
} sock_opts[] = {
    { "SO_BROADCAST",        SOL_SOCKET,    SO_BROADCAST,    sock_str_flag },
    { "SO_DEBUG",            SOL_SOCKET,    SO_DEBUG,        sock_str_flag },
    { "SO_DONTROUTE",        SOL_SOCKET,    SO_DONTROUTE,    sock_str_flag },
    { "SO_ERROR",            SOL_SOCKET,    SO_ERROR,        sock_str_int },
    { "SO_KEEPALIVE",        SOL_SOCKET,    SO_KEEPALIVE,    sock_str_flag },
    { "SO_LINGER",            SOL_SOCKET,    SO_LINGER,        sock_str_linger },
    { "SO_OOBINLINE",        SOL_SOCKET,    SO_OOBINLINE,    sock_str_flag },
    { "SO_RCVBUF",            SOL_SOCKET,    SO_RCVBUF,        sock_str_int },
    { "SO_SNDBUF",            SOL_SOCKET,    SO_SNDBUF,        sock_str_int },
    { "SO_RCVLOWAT",        SOL_SOCKET,    SO_RCVLOWAT,    sock_str_int },
    { "SO_SNDLOWAT",        SOL_SOCKET,    SO_SNDLOWAT,    sock_str_int },
    { "SO_RCVTIMEO",        SOL_SOCKET,    SO_RCVTIMEO,    sock_str_timeval },
    { "SO_SNDTIMEO",        SOL_SOCKET,    SO_SNDTIMEO,    sock_str_timeval },
    { "SO_REUSEADDR",        SOL_SOCKET,    SO_REUSEADDR,    sock_str_flag },
#ifdef    SO_REUSEPORT
    { "SO_REUSEPORT",        SOL_SOCKET,    SO_REUSEPORT,    sock_str_flag },
#else
    { "SO_REUSEPORT",        0,            0,                NULL },
#endif
    { "SO_TYPE",            SOL_SOCKET,    SO_TYPE,        sock_str_int },
    { "SO_USELOOPBACK",        SOL_SOCKET,    SO_USELOOPBACK,    sock_str_flag },
    { "IP_TOS",                IPPROTO_IP,    IP_TOS,            sock_str_int },
    { "IP_TTL",                IPPROTO_IP,    IP_TTL,            sock_str_int },
#ifdef    IPV6_DONTFRAG
    { "IPV6_DONTFRAG",        IPPROTO_IPV6,IPV6_DONTFRAG,    sock_str_flag },
#else
    { "IPV6_DONTFRAG",        0,            0,                NULL },
#endif
#ifdef    IPV6_UNICAST_HOPS
    { "IPV6_UNICAST_HOPS",    IPPROTO_IPV6,IPV6_UNICAST_HOPS,sock_str_int },
#else
    { "IPV6_UNICAST_HOPS",    0,            0,                NULL },
#endif
#ifdef    IPV6_V6ONLY
    { "IPV6_V6ONLY",        IPPROTO_IPV6,IPV6_V6ONLY,    sock_str_flag },
#else
    { "IPV6_V6ONLY",        0,            0,                NULL },
#endif
    { "TCP_MAXSEG",            IPPROTO_TCP,TCP_MAXSEG,        sock_str_int },
    { "TCP_NODELAY",        IPPROTO_TCP,TCP_NODELAY,    sock_str_flag },
#ifdef    SCTP_AUTOCLOSE
    { "SCTP_AUTOCLOSE",        IPPROTO_SCTP,SCTP_AUTOCLOSE,sock_str_int },
#else
    { "SCTP_AUTOCLOSE",        0,            0,                NULL },
#endif
#ifdef    SCTP_MAXBURST
    { "SCTP_MAXBURST",        IPPROTO_SCTP,SCTP_MAXBURST,    sock_str_int },
#else
    { "SCTP_MAXBURST",        0,            0,                NULL },
#endif
#ifdef    SCTP_MAXSEG
    { "SCTP_MAXSEG",        IPPROTO_SCTP,SCTP_MAXSEG,    sock_str_int },
#else
    { "SCTP_MAXSEG",        0,            0,                NULL },
#endif
#ifdef    SCTP_NODELAY
    { "SCTP_NODELAY",        IPPROTO_SCTP,SCTP_NODELAY,    sock_str_flag },
#else
    { "SCTP_NODELAY",        0,            0,                NULL },
#endif
    { NULL,                    0,            0,                NULL }
};
/* *INDENT-ON* */
/* end checkopts1 */

/* include checkopts2 */
int
main(int argc, char **argv)
{
    int                    fd;
    socklen_t            len;
    struct sock_opts    *ptr;

    for (ptr = sock_opts; ptr->opt_str != NULL; ptr++) {
        printf("%s: ", ptr->opt_str);
        if (ptr->opt_val_str == NULL)
            printf("(undefined)\n");
        else {
            switch(ptr->opt_level) {
            case SOL_SOCKET:
            case IPPROTO_IP:
            case IPPROTO_TCP:
                fd = Socket(AF_INET, SOCK_STREAM, 0);
                break;
#ifdef    IPV6
            case IPPROTO_IPV6:
                fd = Socket(AF_INET6, SOCK_STREAM, 0);
                break;
#endif
#ifdef    IPPROTO_SCTP
            case IPPROTO_SCTP:
                fd = Socket(AF_INET, SOCK_SEQPACKET, IPPROTO_SCTP);
                break;
#endif
            default:
                err_quit("Can't create fd for level %d\n", ptr->opt_level);
            }

            len = sizeof(val);
            //不支持的选项应该会引发一个ENOPROTOOPT错误
            if (getsockopt(fd, ptr->opt_level, ptr->opt_name,
                           &val, &len) == -1) {
                err_ret("getsockopt error");
            } else {
                printf("default = %s\n", (*ptr->opt_val_str)(&val, len));
            }
            close(fd);
        }
    }
    exit(0);
}
/* end checkopts2 */

/* include checkopts3 */
static char    strres[128];

static char    *
sock_str_flag(union val *ptr, int len)
{
/* *INDENT-OFF* */
    if (len != sizeof(int))
        snprintf(strres, sizeof(strres), "size (%d) not sizeof(int)", len);
    else
        snprintf(strres, sizeof(strres),
                 "%s", (ptr->i_val == 0) ? "off" : "on");
    return(strres);
/* *INDENT-ON* */
}
/* end checkopts3 */

static char    *
sock_str_int(union val *ptr, int len)
{
    if (len != sizeof(int))
        snprintf(strres, sizeof(strres), "size (%d) not sizeof(int)", len);
    else
        snprintf(strres, sizeof(strres), "%d", ptr->i_val);
    return(strres);
}

static char    *
sock_str_linger(union val *ptr, int len)
{
    struct linger    *lptr = &ptr->linger_val;

    if (len != sizeof(struct linger))
        snprintf(strres, sizeof(strres),
                 "size (%d) not sizeof(struct linger)", len);
    else
        snprintf(strres, sizeof(strres), "l_onoff = %d, l_linger = %d",
                 lptr->l_onoff, lptr->l_linger);
    return(strres);
}

static char    *
sock_str_timeval(union val *ptr, int len)
{
    struct timeval    *tvptr = &ptr->timeval_val;

    if (len != sizeof(struct timeval))
        snprintf(strres, sizeof(strres),
                 "size (%d) not sizeof(struct timeval)", len);
    else
        snprintf(strres, sizeof(strres), "%d sec, %d usec",
                 tvptr->tv_sec, tvptr->tv_usec);
    return(strres);
}

7.4 套接字状态

对于某些套接字选项,针对套接字的状态,什么时候设置或获取选项有时序上的考虑。

TCP已连接套接字选项从监听套接字继承的选项:SO_DEBUG、SO_DONTROUTE、SO_KEEPALIVE、SO_LINGER、SO_OOBINLINE、SO_RCVBUF、SO_RCVLOWAT、SO_SNDBUF、SO_SNDLOWAT、TCP_MAXSEG和TCP_NODELAY。

因为accept一直要到三次握手完成时,才会给服务器返回已连接套接字,如果想在三次握手完成时确保这些套接字选项中的某一个是给已连接套接字设置的,那么必须先给监听套接字设置该选项。

7.5 通用套接字选项

通用套接字选项是协议无关的,由内核中协议无关代码处理。某些通用套接字选项只能应用到某些特定类型的套接字中。

SO_BROADCAST套接字选项

开启或禁止进程发送广播消息的能力,只有数据报套接字支持广播,并且还必须在支持广播消息的网络上。

SO_DEBUG套接字选项

仅由TCP支持,开启本选项时,内核将为TCP在该套接字发送和接收的所有分组保留详细跟踪信息。

SO_DONTROUTE套接字选项

本选项规定外出的分组将绕过底层协议的正常路由机制。

SO_ERROR套接字选项

7.6 ipv4套接字选项

7.7 icmpv6套接字选项

7.8 ipv6套接字选项

7.9 tcp套接字选项

7.10 sctp套接字选项

7.11 fcntl函数

第8章 基本UDP套接字编程

8.1 概述

UDP是无连接不可靠的数据报协议,非常不同于TCP提供的面向连接的可靠字节流。

有些场合确实适合使用UDP,常见的应用程序有:

  • DNS:域名系统
  • NFS:网络文件系统
  • SNMP:简单网络管理协议

image-20200815203716894

8.2 recvfrom和sendto函数

类似于标准的read和write函数,不过需要三个额外的参数:

#include <sys/socket.h>
//成功则均返回读或写的字节数,出错返回-1
ssize_t recvfrom(int sockfd, void *buff, size_t nbytes, int flags,
                struct sockadd *from, socklen_t *addrlen);
ssize_t sendto(int sockfd, void *buff, size_t nbytes, int flags,
              const struct sockaddr *to, socklen_t addrlen);
  • 前三个参数sockfd、buff和nbytes等同于read和write函数的三个参数:描述符、指向读入或写出缓冲区的指针和读写字节数。

  • flags总是置0

  • sendto的to参数指向一个含有数据报接收者的协议地址(例如IP地址及端口号)的套接字地址结构,大小由addrlen参数指定(是一个整数值

  • recvfrom的from参数指向一个将由该函数在返回时填写数据报发送者的协议地址(例如IP地址及端口号)的套接字地址结构,该套接字地址中填写的字节数存放在addrlen参数所指的整数中返回给调用者(是一个指向整数值的指针(值-结果传参)

recvfrom最后两个参数类似accept最后两个参数:返回时其中套接字地址结构内容告诉我们是谁发送了数据报(UPD情况下)或是谁发起了连接(TCP情况下)。

sendto的最后两个参数类似于connect最后两个参数:调用时其中套接字地址结构被我们填入数据报发往(UDP情况下)或与之建立连接(TCP情况下)的协议地址

**写一个长度为0的数据报是可行的。**在UDP情况下,会形成一个只包含IP首部和UDP首部而没有数据的IP数据报,即recvfrom返回0值是可接受的。

UDP是无连接的,不存在关闭连接之类的事情。

8.3 UDP回射服务器程序:main函数

image-20200815211416476

#include    "unp.h"

int
main(int argc, char **argv)
{
    int                    sockfd;
    struct sockaddr_in     servaddr, cliaddr;

    //通过指定SOCK_DGRAM,创建一个UDP套接字
    sockfd = Socket(AF_INET, SOCK_DGRAM, 0);

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family      = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port        = htons(SERV_PORT);

    Bind(sockfd, (SA *) &servaddr, sizeof(servaddr));

    dg_echo(sockfd, (SA *) &cliaddr, sizeof(cliaddr));
}

8.4 UDP回收服务器程序:dg_echo函数

#include    "unp.h"

void
dg_echo(int sockfd, SA *pcliaddr, socklen_t clilen)
{
    int            n;
    socklen_t      len;
    char           mesg[MAXLINE];

    //迭代服务器,永不终止,无连接
    for ( ; ; ) {
        len = clilen;
        n = Recvfrom(sockfd, mesg, MAXLINE, 0, pcliaddr, &len);

        Sendto(sockfd, mesg, n, 0, pcliaddr, len);
    }
}

大多数TCP服务器是并发的,大多数UDP服务器是迭代的。每个UDP套接字都有一个接收缓冲区,到达该套接字的每个数据报都进入这个套接字接收缓冲区,当进程调用recvfrom函数时,缓冲区中的下一个数据报以FIFO顺序返回给进程。

dg_echo函数是协议无关的:调用者分配一个正确大小的套接字地址结构,将其地址指针和大小传参给dg_echo,dg_echo绝不查看该结构的内容,而是把一个指向该结构的指针传递给recvfrom和sendto。

image-20200815212534596

image-20200815212550160

8.5 UDP回射客户程序:main函数

#include    "unp.h"

int
main(int argc, char **argv)
{
    int                    sockfd;
    struct sockaddr_in     servaddr;

    if (argc != 2)
        err_quit("usage: udpcli <IPaddress>");

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(SERV_PORT);
    Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);

    sockfd = Socket(AF_INET, SOCK_DGRAM, 0);

    dg_cli(stdin, sockfd, (SA *) &servaddr, sizeof(servaddr));

    exit(0);
}

8.6 UDP回射客户程序:dg_cli函数

dg_cli函数也是协议无关的,不过main函数都是协议相关的。

#include    "unp.h"

void
dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)
{
    int     n;
    char    sendline[MAXLINE], recvline[MAXLINE + 1];

    while (Fgets(sendline, MAXLINE, fp) != NULL) {

        //首次调用sendto时没有绑定一个本地接口,内核在此时为它选择一个临时端口
        Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);

        //最后两个参数是空指针,表示并不关心应答数据报由谁发送
        //任何接收的数据报均被认为是服务器的内容
        n = Recvfrom(sockfd, recvline, MAXLINE, 0, NULL, NULL);

        recvline[n] = 0;    /* null terminate */
        Fputs(recvline, stdout);
    }
}

8.7 数据报的丢失

UDP客户/服务器例子是不可靠的:如果客户数据报到达服务器,但是服务器的应答丢失了,则客户将永远阻塞于dg_cli函数的recvfrom调用,等待一个永远不会到达的服务器应答。

防止永久阻塞的一般方法是给客户的recvfrom调用设置一个超时,但是这并不是完整的解决办法。

8.8 验证接收到的响应

知道客户临时端口的任何进程都可以往客户发送数据报,而这些数据报会与正常的服务器应答混杂。

通过在dg_cli函数的recvfrom调用中,通知内核返回数据报发送者的地址,通过比较recvfrom在值-结果传参中返回的长度,然后用memcmp比较套接字地址结构本身,验证接收到的响应。

#include    "unp.h"

void
dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)
{
    int                    n;
    char                sendline[MAXLINE], recvline[MAXLINE + 1];
    socklen_t              len;
    struct sockaddr        *preply_addr;

    preply_addr = Malloc(servlen);

    while (Fgets(sendline, MAXLINE, fp) != NULL) {

        Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);

        len = servlen;
        n = Recvfrom(sockfd, recvline, MAXLINE, 0, preply_addr, &len);
        if (len != servlen || memcmp(pservaddr, preply_addr, len) != 0) {
            printf("reply from %s (ignored)\n",
                    Sock_ntop(preply_addr, len));
            continue;
        }

        recvline[n] = 0;    /* null terminate */
        Fputs(recvline, stdout);
    }
}

如果服务器运行在只有单个IP的主机上,那么新版的客户将正常工作,如果服务器主机是多宿的,该客户可能失败:发送到服务器数据的地址和接收服务器数据的地址可能不同。

解决办法:

  • 将返回的IP地址通过DNS中查找服务器主机的名字来验证主机的域名
  • 为服务器每个IP绑定一个套接字,在所有套接字上使用select,使应答的套接字上绑定的IP地址就是客户请求的目的IP地址

8.9 服务器进程未运行

服务器进程不启动的情况下,客户永远阻塞在它的recvfrom调用,等待一个永不出现的服务器应答:

  • 客户主机进行ARP请求后获取服务器地址
  • 客户发送数据后,返回“端口不可达”的ICMP消息,但该消息不会返回给客户进程

这个ICMP错误称为异步错误,该错误由sendto引起,但是sendto本身却成功返回。UDP输出操作成功后仅仅返回表示在接口输出队列中具有存放所形成IP数据报的空间,该ICMP错误直到后来才返回,故称其为异步。

一个基本的规则:对于一个UDP套接字,由它引起的异步错误却并不返回给它,除非它已连接。ICMP出错信息包含引起错误的数据报的IP首部和UDP首部,而recvfrom可以返回的信息只有errno值,没法返回出错数据报的目的IP地址和目的UDP端口号,因此做出决定:仅在进程已将其UDP套接字连接到恰恰一个对端后,这些异步错误才返回给进程。

只要SO_BSDCOMPAT套记者选项没有开启,Linux甚至对未连接的套接字也返回大多数ICMP “destination unreachable”错误。

8.10 UDP程序例子小结

image-20200816101704301

  • 客户临时端口是在第一次调用sendto时一次性选定,不能改变
  • 客户的IP地址可以随客户发送的每个UDP数据报而变动

image-20200816101727333

  • 对于UDP套接字来是,目的IP地址只能通过为IPv4设置IP_RECVDSTADDR套接字选项(或为IPv6设置IPV6_PKTINFO套接字选项)然后调用recvmsg取得。

8.11 UDP的connect函数

UDP套接字的connect没有三路握手过程,内核只是检查是否存在立即可知的错误,记录对端的IP地址和端口号,然后立即返回到调用进程:

  • 未连接UDP套接字,新创建的UDP套接字默认如此
  • 已连接UDP套接字,对UDP套接字调用connect的结果

已连接UDP套接字对比默认的未连接套接字的三个变化:

  • 不能给输出操作指定目的IP地址和端口号,而是改用write或send

    • 不使用sendto
    • 使用sendto,但是不能指定目的地址,sendto的第五个参数为空指针,第六个参数为0

    POSIX规范指出当第五个参数是空指针时,第六个参数的取值就不再考虑

  • 不必使用recvfrom以获悉数据报的发送者,而改用read、recv或recvmsg。限制一个已连接UDP套接字能且仅能与一个对端交换数据报。

    • 准确说是仅能与一个IP地址交换数据报,可能connect到多播或广播地址
  • 已连接UDP套接字的异步错误会返回给它们所在的进程,而未连接UDP套接字不接受任何异步错误

image-20200816103351241

image-20200816103527775

UDP客户进程或服务进程只在使用自己的UDP套接字与确定的唯一对端进行通信时,才可以调用connect,调用connect的通常是UDP客户,不过有些网络应用中的UDP服务器会与单个客户长时间通信(如TFTP),这种情况下,客户和服务器都可能调用connect。

8.11.1 给一个UDP套接字多次调用connect

一个已连接UDP套接字的进程可由下列两个目的再次调用connect:

  • 指定新的IP地址和端口号
    • TCP套接字connect只能调用一次
  • 断开套接字
    • 为了断开一个已连接UDP套接字,我们再次调用connect时把套接字地址结构的地址族成员(对于IPv4为sin_family,对于IPv6为sin6_family)设置为AF_UNSPEC,这样做可能会返回一个EAFNOSUPPORT错误,不过没关系
    • 有些系统可以用空的套接字地址结构指针调用connect,POSIX规范和BSD手册只是提示必须使用一个空地址而没有提到出错返回值。最便于移植的解决办法是:清零一个地址结构后把它的地址族成员设置为AF_UNSPEC,再把它传递给connect

8.11.2 性能

在一个未连接UDP套接字上给两个数据报调用sendto函数涉及6个步骤(源自Berkeley内核):

  • 连接套接字(第一次很可能搜索路由表)
  • 输出第一个数据报
  • 断开套接字连接
  • 连接套接字(第二次的目的地址可能等同第一次,则无需搜索路由表)
  • 输出第二个数据报
  • 断开套接字连接

当应用进程知道自己要给同一目的地址发送多个数据报时,显示连接套接字效率更高,调用connect后调用两次write涉及内核的执行步骤如下:

  • 连接套接字
  • 输出第一个数据报
  • 输出第二个数据报

8.12 dg_cli函数(修订版)

#include    "unp.h"

void
dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)
{
    int            n;
    char        sendline[MAXLINE], recvline[MAXLINE + 1];

    Connect(sockfd, (SA *) pservaddr, servlen);

    while (Fgets(sendline, MAXLINE, fp) != NULL) {

        Write(sockfd, sendline, strlen(sendline));

        n = Read(sockfd, recvline, MAXLINE);

        recvline[n] = 0;    /* null terminate */
        Fputs(recvline, stdout);
    }
}

函数不查看传递给connect的套接字地址结构的内容,仍是协议无关的。

8.13 UDP缺乏流量控制

UDP套接字接收缓冲区:由UDP给某个特定套接字排队的UDP数据报数目受限于该套接字接收缓冲区的大小,可以使用SO_RCVBUF套接字选项修改改制。

增加流量控制的UDP示例:

#include    "unp.h"

#define    NDG        2000    /* datagrams to send */
#define    DGLEN    1400    /* length of each datagram */

void
dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)
{
    int        i;
    char    sendline[DGLEN];

    for (i = 0; i < NDG; i++) {
        Sendto(sockfd, sendline, DGLEN, 0, pservaddr, servlen);
    }
}
#include    "unp.h"

static void    recvfrom_int(int);
static int    count;

void
dg_echo(int sockfd, SA *pcliaddr, socklen_t clilen)
{
    int            n;
    socklen_t      len;
    char           mesg[MAXLINE];

    Signal(SIGINT, recvfrom_int);

    n = 220 * 1024;
    Setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &n, sizeof(n));

    for ( ; ; ) {
        len = clilen;
        Recvfrom(sockfd, mesg, MAXLINE, 0, pcliaddr, &len);

        count++;
    }
}

static void
recvfrom_int(int signo)
{
    printf("\nreceived %d datagrams\n", count);
    exit(0);
}

8.14 udp中的外出接口的确定

已连接UDP套接字还可以用来确定用于某个特定目的地址的外出接口。因为connect函数应用到UDP套接字时有一个副作用:内核选择本地IP地址(未使用bind),这个本地IP地址通过为目的地址搜索路由表得到外出接口,然后选用该接口的主IP地址而选定。

在UDP套接字上调用connect并不给对端主机发送任何信息,它完全是一个本地操作,只是保存对端的IP地址和端口号。

在一个未绑定端口号的UDP套接字上调用connect同时也给该套接字指派一个临时端口。

//使用connect来确定输出接口的UDP程序
#include    "unp.h"

int
main(int argc, char **argv)
{
    int                        sockfd;
    socklen_t                  len;
    struct sockaddr_in         cliaddr, servaddr;

    if (argc != 2)
        err_quit("usage: udpcli <IPaddress>");

    sockfd = Socket(AF_INET, SOCK_DGRAM, 0);

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(SERV_PORT);
    Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);

    Connect(sockfd, (SA *) &servaddr, sizeof(servaddr));

    len = sizeof(cliaddr);
    Getsockname(sockfd, (SA *) &cliaddr, &len);
    printf("local address %s\n", Sock_ntop((SA *) &cliaddr, len));

    exit(0);
}

8.15 使用select函数的tcp和udp回射服务器程序

将并发TCP回射服务器程序与迭代UDP回射服务器程序组合成单个使用select来复用TCP和UDP套接字的服务器程序。

/* include udpservselect01 */
#include    "unp.h"

int
main(int argc, char **argv)
{
    int                    listenfd, connfd, udpfd, nready, maxfdp1;
    char                   mesg[MAXLINE];
    pid_t                  childpid;
    fd_set                 rset;
    ssize_t                n;
    socklen_t              len;
    const int              on = 1;
    struct sockaddr_in     cliaddr, servaddr;
    void                   sig_chld(int);

        /* create listening TCP socket */
    listenfd = Socket(AF_INET, SOCK_STREAM, 0);

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family      = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port        = htons(SERV_PORT);

    //设置SO_REUSEADDR套接字选项防止该端口上已有连接存在
    Setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
    Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));

    Listen(listenfd, LISTENQ);

        /* create UDP socket */
    udpfd = Socket(AF_INET, SOCK_DGRAM, 0);

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family      = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port        = htons(SERV_PORT);

    Bind(udpfd, (SA *) &servaddr, sizeof(servaddr));
/* end udpservselect01 */

/* include udpservselect02 */
    //给SIGCHLD建立信号处理程序,因为TCP连接将由某个子进程处理
    Signal(SIGCHLD, sig_chld);    /* must call waitpid() */

    FD_ZERO(&rset);
    maxfdp1 = max(listenfd, udpfd) + 1;
    for ( ; ; ) {
        FD_SET(listenfd, &rset);
        FD_SET(udpfd, &rset);
        if ( (nready = select(maxfdp1, &rset, NULL, NULL, NULL)) < 0) {
            if (errno == EINTR)
                continue;        /* back to for() */
            else
                //sig_chld信号处理程序可能会中断select调用,需要处理EINTR错误
                err_sys("select error");
        }

        if (FD_ISSET(listenfd, &rset)) {
            len = sizeof(cliaddr);
            connfd = Accept(listenfd, (SA *) &cliaddr, &len);
    
            if ( (childpid = Fork()) == 0) {    /* child process */
                Close(listenfd);    /* close listening socket */
                str_echo(connfd);    /* process the request */
                exit(0);
            }
            Close(connfd);            /* parent closes connected socket */
        }

        if (FD_ISSET(udpfd, &rset)) {
            len = sizeof(cliaddr);
            n = Recvfrom(udpfd, mesg, MAXLINE, 0, (SA *) &cliaddr, &len);

            Sendto(udpfd, mesg, n, 0, (SA *) &cliaddr, len);
        }
    }
}
/* end udpservselect02 */